package domein;

import java.io.BufferedReader;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.FileReader;
import java.io.FileWriter;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.ObjectOutputStream;
import java.io.PrintWriter;
import java.net.InetAddress;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Observer;
import java.util.Random;
import java.util.Set;
import java.util.TreeSet;

/**
 *
 * @author Colin Bundervoet
 * @author Jeroen Pattyn
 *
 * Server Object, holds a private inner class for connections.
 */
public class Server
{
    private int port;
    private List<ClientThread> clients;
    private Set<String> operators;
    private Set<String> autoKicked;
    private Set<String> ipBanned;
    private String topic;
    private ServerSocket serverSocket;
    private MessageObservable serverLog;
    private OnlineUsersObservable onlineUsersObservable;
    private StateObservable stateObservable;
    private Random random;

    /**
     * Default constructor for creating a Server object.
     *
     */
    public Server() {
        serverLog = new MessageObservable();
        onlineUsersObservable = new OnlineUsersObservable();
        stateObservable = new StateObservable();
        random = new Random();
    }

    /**
     * Adds observer to update gui of server messages.
     *
     * @param o Observer to observe Server
     */
    public void addServerLogObserver(Observer o) {
        serverLog.addObserver(o);
    }

    /**
     * Adds observer to update gui of online users.
     *
     * @param o Observer to observe Server
     */
    public void addOnlineUsersObserver(Observer o) {
        onlineUsersObservable.addObserver(o);
    }

    /**
     * Adds observer to update gui of client connection state
     *
     * @param o Observer to observe Client
     */
    public void addStateObserver(Observer o) {
        stateObservable.addObserver(o);
    }

    private void setPort(int port) throws PortOutOfRangeException {
        if (port > -1 && port < 65536) {
            this.port = port;
        }
        else {
            throw new PortOutOfRangeException(port, 0, 65535);
        }
    }

    /**
     * Starts the server if using the string port to listen on. If the port is
     * incorrect nothing will happen.
     *
     * @param port String representation of port to start on.
     */
    public void start(String port, boolean localhost) {
        try {
            start(Integer.parseInt(port), localhost);
        }
        catch (NumberFormatException ex) {
            showOnLog("Portnumber has to be a number in this range [0-65535]");
        }
    }

    /**
     * Starts the server, initializing all components. Starting is not invoked
     * in the constructor and must be done separately. Always invoke this method
     * within a separate thread. Whichever thread it is invoked in will be
     * blocked, including ui threads.
     */
    private void start(int port, boolean localhost) {
        try {
            setPort(port);
            clients = new ArrayList<>();
            if(localhost){
                serverSocket = new ServerSocket(port);
            } else{
                serverSocket = new ServerSocket(port, 0, InetAddress.getLocalHost());
            }
            port = serverSocket.getLocalPort();
            if (this.port != port) {
                this.port = port;
            }
            stateObservable.updateState(true);
            operators = fileReader("ops.txt");
            autoKicked = fileReader("kicked.txt");
            ipBanned = fileReader("banned.txt");
            topic = "";
            while (stateObservable.getState()) {
                String IP = serverSocket.getInetAddress().toString();
                IP = IP.substring(IP.indexOf("/") + 1);
                showOnLog("Waiting for connections...");
                showOnLog("Address: " + IP + ":" + this.port);

                Socket clientSocket = stateObservable.getState() ? serverSocket.accept() : null;
                if (ipBanned.contains(clientSocket.getInetAddress().toString())) {
                    showOnLog(clientSocket.getInetAddress().toString()
                              + " tried to connect.");
                    try (ObjectOutputStream ooStream = new ObjectOutputStream(clientSocket.getOutputStream())) {
                        ooStream.writeObject(new Message(MessageType.MESSAGE, "You are banned."));
                        ooStream.flush();
                    }
                    clientSocket.close();
                }
                else {
                    ObjectOutputStream ooStream =
                                       new ObjectOutputStream(clientSocket.getOutputStream());
                    ClientThread client =
                                 new ClientThread(clientSocket, ooStream);
                    clients.add(client);

                    Thread listener = new Thread(client);
                    listener.start();
                    showOnLog("New connection on " + clientSocket.getInetAddress().toString());
                }
            }
        }
        catch (PortOutOfRangeException ex) {
            showOnLog(ex.getMessage());
        }
        catch (Exception ex) {
            if (stateObservable.getState()) {
                showOnLog("Server has crashed, please restart the program");
            }
            else {
                showOnLog("Server shut down");
                stateObservable.updateState(false);
            }
            disconnectAllUsers();
        }
    }

    /**
     * Will read the file and loads all it's members into a TreeSet. Each entry
     * in the set will be taken from every newline in the file. If the file
     * doesn't exist the file will be created. method is invoked when starting
     * the server or forcing the /refresh command from a user.
     *
     * @param filename containing the data to be read.
     * @return Set containing all string representations from the file.
     */
    private Set<String> fileReader(String filename) {
        Set<String> users = new TreeSet<>();
        BufferedReader in;
        try {
            in = new BufferedReader(new FileReader(filename));
            while (in.ready()) {
                users.add(in.readLine());
            }

            in.close();
            showOnLog("Reading " + filename + " complete.");
        }
        catch (FileNotFoundException ex) {
            showOnLog("File " + filename + " not found, creating...");
            fileWriter(filename, users);
        }
        catch (IOException ex) {
            showOnLog("File " + filename + " could not be read");
        }
        return users;
    }

    /**
     * Creates a new file with parameter filename and persists all the data.
     * This method should only be invoked by the saveFiles() method.
     *
     * @param filename of the file to be written to.
     * @param users Set of characters to be written to the file.
     */
    private void fileWriter(String filename, Set<String> users) {
        PrintWriter out;

        try {
            out = new PrintWriter(new FileWriter(new File(filename)));
            for (String user : users) {
                out.println(user);
            }
            out.close();
            showOnLog("Writing " + filename + " complete.");
        }
        catch (IOException ex) {
            showOnLog("Error writing to " + filename);
        }
    }

    /**
     * saves each of the sets for operators, autokicked members and banned ips
     * This method should be invoked whenever changing any of these sets. To
     * remain consistent after possible server resets and restarts.
     */
    private void saveFiles() {
        fileWriter("ops.txt", operators);
        fileWriter("kicked.txt", autoKicked);
        fileWriter("banned.txt", ipBanned);
    }

    /**
     * Stops the server and disconnects all the members. Should be invoked
     * before trying to set this object to null.
     */
    public void stop() {
        stateObservable.updateState(false);
        try {
            serverSocket.close();
        }
        catch (IOException ex) {
            showOnLog("error trying to close socket.");
        }
    }

    /**
     * Adds the message to the log. Updates it's observers of whatever data to
     * append to the log
     *
     * @param logmessage message to be logged.
     */
    public void showOnLog(String logmessage) {
        serverLog.addMessage(logmessage);
    }

    /**
     * Sends a revised userlist to all the connected users. Should only be
     * called whenever the userlist changed. ie, during changes in nickname,
     * kicked members, leaving members, or timed out members. Default invoked
     * when a writer invokes its's close() method.
     */
    public synchronized void updateUserLists() {
        List<String> users = getConnectedUsers();
        Collections.sort(users);
        String msg = "";
        for (String user : users) {
            msg += "," + user;
        }
        try {
            if (msg.length() != 0) {
                msg = msg.substring(1);
                sendToAll(new Message(MessageType.ONLINEUSERS, msg));
            }
        }
        catch (Exception ex) {
            showOnLog("Error while updating online users.");
        }
        String[] userlist = new String[users.size()];
        for (int i = 0; i < users.size(); i++) {
            userlist[i] = users.get(i);
        }

        onlineUsersObservable.updateOnlineUsers(userlist);
    }

    /**
     * Sends a raw message to all connected users.
     *
     * @param message String message to be sent to all users.
     */
    private synchronized void sendToAll(Message message) {
        try {
            for (ClientThread client : clients) {
                client.sendMessage(message);
            }

        }
        catch (Exception ex) {
            showOnLog("error telling everyone");
        }
    }

    /**
     * calls sendToAll() with the message wrapped in a Message-object
     *
     * @param message String message to be sent to all users.
     */
    private synchronized void sendToAll(String message) {
        sendToAll(new Message(MessageType.MESSAGE, message));
    }

    /**
     * Will send a users online list to whoever invokes the specific request.
     * the provided writer's outputstream will be used to send the message. This
     * method call is meant for non-GUI updating list view.
     *
     * @param writer invokee of the function to return list to.
     */
    private void whoIsOnline(ClientThread client) {
        String message = "Currently online: ";
        for (String user : getConnectedUsers()) {
            message += "\n- " + user;
        }
        client.sendMessage(message);
        updateUserLists();
    }

    /**
     * Finds the ClientThread object with the provided username. Username is a
     * unique value for which to find a specific ClientThread. Should only be
     * invoked if trying to write to a specific outputstream which does not the
     * current user's stream or using the multicast option.
     *
     * @param username of the ClientThread to be found. Username is unique.
     * @return ClientThread object associated with specific username. returns
     * null in case of not found.
     */
    private ClientThread getUser(String username) {
        ClientThread ret = null;
        if (clients != null) {
            try {
                for (ClientThread client : clients) {
                    if (username.equals(client.username)) {
                        ret = client;
                    }
                }
            }
            catch (Exception ex) {
                showOnLog("Error grabbing username " + username);
            }
        }
        return ret;
    }

    /**
     * Returns a String representation of all currently connected users in no
     * particular order. Implemented for ease of manipulation by in other
     * components.
     *
     * @return Array String list of connected users.
     */
    private List<String> getConnectedUsers() {
        List<String> users = new ArrayList<>();

        if (clients != null) {
            try {
                for (ClientThread client : clients) {
                    users.add(client.username);
                }
            }
            catch (Exception ex) {
                showOnLog("Error grabbing usernames.");
            }
        }
        return users;
    }

    /**
     * Disconnects all currently connected users, should be invoked when
     * stopping the current server process.
     */
    private void disconnectAllUsers() {
        for (ClientThread client : clients) {
            client.anonymousClose();
        }
        clients.clear();
    }

    /**
     * kicks the selected user from the server. Closing the connection and
     * notifying all connected members.
     *
     * @param username to be kicked.
     */
    public void kick(String username) {
        ClientThread ct = getUser(username);
        if (ct != null) {
            sendToAll(ct.username + " was kicked.");
            showOnLog(ct.username + " was kicked.");
            ct.close();
        }
        else {
            showOnLog("No such user " + username);
        }
    }

    /**
     * Adds connected user to ban list and kicks that user. All new connections
     * by this user will not be accepted.
     *
     * @param username pf the individual to be banned.
     * @return boolean to notify success or failure for any stream components.
     */
    public boolean ban(String username) {
        ClientThread ct = getUser(username);

        if (ct != null) {
            ipBanned.add(ct.sock.getInetAddress().toString());
            showOnLog(ct.sock.getInetAddress().toString() + " was banned");
            kick(username);
        }
        saveFiles();

        return ct != null;
    }

    /**
     * Sets mode operator on specified username. If user is already op nothing
     * will change. Can be invoked for unconnected users, will
     *
     * @param username user to be made operator.
     */
    public void op(String username) {
        operators.add(username);
        saveFiles();
        ClientThread ct = getUser(username);
        if (ct != null) {
            ct.sendMessage("You are now op.");
        }
        sendToAll("Setting mode operator on " + username);
    }

    /**
     * Removes mode operator from specified username. Can be invoked on
     * non-operator members, will not change their status. Can be invoked for
     * unconnected members, will change their status if it was operator.
     *
     * @param username user to be made regular user
     */
    public void deop(String username) {
        operators.remove(username);
        saveFiles();
        ClientThread ct = getUser(username);
        if (ct != null) {
            ct.sendMessage("You are no longer op.");

        }
        sendToAll("Removing mode operator on " + username);
    }

    /**
     * Looks up the whois innformation for a specific user.
     *
     * @param username user whose information is looked up
     * @return string containing information.
     */
    public String whois(String username) {
        ClientThread ct = getUser(username);

        if (ct != null) {
            return ct.username + "'s IP address is " + ct.sock.getInetAddress().toString();
        }
        else {
            return username + " could not be found.";
        }
    }

    /**
     * inner class for ClientThreads. Every new ClientThread represents a single
     * connected user.
     */
    private class ClientThread implements Runnable
    {
        private BufferedReader reader;
        private Socket sock;
        private ObjectOutputStream ooStream;
        protected String username;
        private boolean firstConnect;
        private boolean disconnecting;
        private boolean clientdisconnect;

        /**
         * Constructor for creating a new ClientThread. Requires clientsocket to
         * listen on, and the writer to write on.
         *
         * @param clientSocket Socket object to listen in on a specific
         * connection.
         * @param ooStream PrintWriter object to which this writer needs to
         * write it's output
         */
        public ClientThread(Socket clientSocket, ObjectOutputStream ooStream) {
            this.ooStream = ooStream;
            firstConnect = true;
            try {
                sock = clientSocket;
                reader = new BufferedReader(new InputStreamReader(sock.getInputStream()));
            }
            catch (Exception ex) {
                showOnLog("error beginning StreamReader");
            }
        }

        /**
         * Changes the user's nickname to the specified nickname. notifies all
         * other users of this change. if the connection happens for the first
         * time the rules for choosing is much stricter. If the current nickname
         * is on the autokick list, Choosing it at any point at runtime will
         * close your connection.
         *
         * @param nick new nickname to be used as unique identifier.
         */
        public void changeNick(String nick) {
            String regex = "[A-Za-z0-9]*";
            if (nick.matches(regex)) {
                if (getConnectedUsers().contains(nick)) {
                    sendMessage("Your username is already taken");
                    if (username == null) {
                        changeNick("user" + clients.size() + random.nextInt(999999));
                    }
                }
                else {
                    if (autoKicked.contains(nick)) {
                        sendMessage("username not allowed");
                        if (username == null) {
                            changeNick("user" + clients.size() + random.nextInt(999999));
                        }
                    }
                    else {
                        if (!firstConnect) {
                            sendToAll("" + username + " has changed their nickname to " + nick);
                        }
                        username = nick;
                        updateUserLists();
                    }
                }
            }
            else {
                if (username == null) {
                    changeNick("user" + clients.size() + random.nextInt(999999));
                }
                sendMessage("You username could not be changed");
            }

        }

        /**
         * Closes this stream and removes it from the main thread's list of
         * clients. Should be invoked whenever a user decides to disconnect or
         * when a is at any point removed from the current conversation.
         */
        public void close() {
            try {
                disconnecting = true;
                if (!clientdisconnect) {
                    sendMessage("Server: You are disconnecting");
                }
                sock.close();
                reader.close();
                clients.remove(this);
                updateUserLists();
                showOnLog(username + " is disconnected");
            }
            catch (IOException ex) {
                showOnLog(username + " could not be disconnected.");
            }
        }

        public void anonymousClose() {
            try {
                ooStream.close();
                reader.close();
                sock.close();
            }
            catch (IOException ex) {
                showOnLog(username + " could not be disconnected.");
            }
        }

        /**
         * provides main parsing of incoming data, data proceeded by "/" will be
         * parsed into the correct action. Please refer to the manual for
         * specific instructions. Other messages will be multicast properly.
         */
        @Override
        public void run() {
            String message;
            try {
                while ((message = reader.readLine()) != null) {
                    if (firstConnect) {
                        String[] command = message.split(" ");
                        changeNick(command[1]);
                        sendMessage("The topic is: \"" + topic + "\"");
                        sendToAll(username + " has connected.");
                        firstConnect = false;
                        updateUserLists();
                    }
                    else {
                        ClientThread ct = null;
                        String sendMessage = null;
                        if (message.charAt(0) == '/') {
                            showOnLog(username + "'s command: " + message);
                            String[] command = message.split(" ");
                            switch (command[0]) {
                                case "/nick":
                                    changeNick(command[1]);
                                    break;
                                case "/who":
                                    whoIsOnline(this);
                                    break;
                                case "/me":
                                case "/say":
                                    try {
                                        sendMessage = "* " + username + " ";
                                        for (int i = 1; i < command.length; i++) {
                                            sendMessage += command[i] + " ";
                                        }
                                        sendToAll(sendMessage);
                                    }
                                    catch (IndexOutOfBoundsException ex) {
                                        sendMessage("Could not parse, invalid use of " + command[0]);
                                    }
                                    break;
                                case "/":
                                    sendMessage("Stuck?! Try /help for extra help...");
                                    break;
                                case "/help":
                                    getHelp();
                                    break;
                                case "/pm":
                                case "/w":
                                case "/whisper":
                                    try {
                                        sendMessage = "[" + username + " > " + command[1] + "] ";
                                        for (int i = 2; i < command.length; i++) {
                                            sendMessage += command[i] + " ";
                                        }
                                        ct = getUser(command[1]);
                                        if (ct != null) {
                                            ct.sendMessage(sendMessage);
                                            sendMessage(sendMessage);
                                        }
                                        else {
                                            sendMessage(command[1] + " is currently not online.");
                                        }
                                    }
                                    catch (IndexOutOfBoundsException ex) {
                                        sendMessage("Could not parse, invalid use of " + command[0]);
                                    }
                                    break;
                                case "/quit":
                                case "/disconnect":
                                case "/dc":
                                case "/leave":
                                    clientdisconnect = true;
                                    close();
                                    break;

                                case "/op":
                                    try {
                                        if (operators.contains(username)) {
                                            op(command[1]);
                                        }
                                        else {
                                            sendMessage("You are not op.");
                                        }
                                    }
                                    catch (IndexOutOfBoundsException ex) {
                                        sendMessage("Could not parse, invalid use of " + command[0]);
                                    }
                                    break;
                                case "/deop":
                                    try {

                                        if (operators.contains(username)) {
                                            deop(command[1]);
                                        }
                                        else {
                                            sendMessage("You are not op.");
                                        }
                                    }
                                    catch (IndexOutOfBoundsException ex) {
                                        sendMessage("Could not parse, invalid use of " + command[0]);
                                    }
                                    break;

                                case "/kick":
                                    try {
                                        if (operators.contains(username)) {
                                            kick(command[1]);
                                        }
                                        else {
                                            sendMessage("You are not op.");
                                        }
                                    }
                                    catch (IndexOutOfBoundsException ex) {
                                        sendMessage("Could not parse, invalid use of " + command[0]);
                                    }
                                    break;
                                case "/autokick":
                                    try {


                                        if (operators.contains(username)) {
                                            autoKicked.add(command[1]);

                                            kick(command[1]);
                                            saveFiles();
                                        }
                                        else {
                                            sendMessage("You are not op.");
                                        }
                                    }
                                    catch (IndexOutOfBoundsException ex) {
                                        sendMessage("Could not parse, invalid use of " + command[0]);
                                    }
                                    break;
                                case "/refresh":
                                    if (operators.contains(username)) {
                                        ipBanned = fileReader("banned.txt");
                                        autoKicked = fileReader("kicked.txt");
                                        operators = fileReader("ops.txt");
                                        updateUserLists();
                                        sendMessage("Refreshing files complete");
                                    }
                                    else {
                                        sendMessage("You are not op.");
                                    }
                                    break;
                                case "/topic":
                                    try {

                                        if (operators.contains(username)) {
                                            sendMessage = username + " has changed topic to \"";
                                            String newtopic = "";
                                            for (int i = 1; i < command.length; i++) {
                                                sendMessage += command[i] + " ";
                                                newtopic += command[i] + " ";
                                            }
                                            sendMessage += "\"";
                                            topic = newtopic.trim();
                                            sendToAll(sendMessage.trim());
                                        }
                                        else {
                                            sendMessage("You are not op.");
                                        }
                                    }
                                    catch (IndexOutOfBoundsException ex) {
                                        sendMessage("Could not parse, invalid use of " + command[0]);
                                    }
                                    break;
                                case "/ipban":
                                case "/ban":
                                    try {
                                        if (operators.contains(username)) {
                                            if (!ban(command[1])) {
                                                sendMessage("User not online");
                                            }
                                        }
                                        else {
                                            sendMessage("You are not op.");
                                        }
                                    }
                                    catch (IndexOutOfBoundsException ex) {
                                        sendMessage("Could not parse, invalid use of " + command[0]);
                                    }
                                    break;

                                case "/ip":
                                    try {
                                        ct = getUser(command[1]);

                                        if (ct != null) {
                                            sendMessage(ct.sock.getInetAddress().toString().substring(1));
                                        }
                                    }
                                    catch (IndexOutOfBoundsException ex) {
                                        sendMessage("Could not parse, invalid use of " + command[0]);
                                    }
                                    break;
                                case "/whois":
                                    try {
                                        ct = getUser(command[1]);

                                        if (ct != null) {
                                            sendMessage(ct.username + "'s IP address is " + ct.sock.getInetAddress().toString());
                                        }
                                    }
                                    catch (IndexOutOfBoundsException ex) {
                                        sendMessage("Could not parse, invalid use of " + command[0]);
                                    }
                                    break;
                                case "/getrole":
                                    try {
                                        if (operators.contains(command[1])) {
                                            sendMessage(command[1] + " is op.");
                                        }
                                        else {
                                            sendMessage(command[1] + " is a user.");
                                        }
                                    }
                                    catch (IndexOutOfBoundsException ex) {
                                        if (operators.contains(username)) {
                                            sendMessage("You are op.");
                                        }
                                        else {
                                            sendMessage("You are a user.");
                                        }
                                    }
                                    break;
                                case "/filetransfer":
                                    try {
                                        ct = getUser(command[1]);
                                        if (ct != null) {
                                            String[] fileParser = message.split(">");
                                            String filename = fileParser[1];
                                            int filesize = Integer.parseInt(fileParser[2]);
                                            int port = Integer.parseInt(fileParser[3]);
                                            String ip = sock.getInetAddress().toString().substring(1);
                                            MessageFileMetaData mfmd = new MessageFileMetaData(filename, filesize, port, ip, username);
                                            ct.sendMessage(mfmd);
                                        }
                                    }
                                    catch (IndexOutOfBoundsException ex) {
                                        sendMessage("Malformed file transfer command, please try again.");
                                    }
                                    break;
                                default:
                                    sendMessage("Could not parse command.\n" + command[0], username + " tried to send command " + message);
                                    break;
                            }
                        }
                        else {
                            String[] lines = message.split("\n");
                            if (message.trim().length() == 0) {
                                showOnLog("<" + username + "> " + "");
                            }
                            else {
                                showOnLog("<" + username + "> " + message);
                                sendToAll("<" + username + "> " + message);
                            }
                        }
                    }
                }
            }
            catch (Exception ex) {
                if (!disconnecting) {
                    showOnLog("Connection of " + username + " on " + sock.getInetAddress() + " dropped");
                }
                else {
                    updateUserLists();
                }
            }
        }

        /**
         * calls sendMessage() with the message wrapped in a Message-object
         *
         * @param message any string message.
         */
        public void sendMessage(String message) {
            sendMessage(new Message(MessageType.MESSAGE, message));
        }

        /**
         * Sends a message over the network to this specific user. Only invoke
         * this method to send messages to specific clients.
         *
         * @param message Message object.
         */
        public void sendMessage(Message message) {
            try {
                ooStream.writeObject(message);
                ooStream.flush();
            }
            catch (IOException ex) {
                showOnLog("An error occurred while sending the message to the client.");
//                ex.printStackTrace();
            }
        }

        /**
         * Sends a message with a corresponding logged message in one method
         * call. overload for sendMessage(String message)
         *
         * @param message any string message
         * @param logMessage message to be print on the server log.
         */
        public void sendMessage(String message, String logMessage) {
            sendMessage(message);
            showOnLog(logMessage);
        }

        /**
         * Returns the help message to the user.
         */
        public void getHelp() {
            sendMessage("So, let me help you out\n"
                        + "1. To send a message, just write it and click the send button\n"
                        + "2. Want to change your nickname? Try /nick\n"
                        + "3. /who to see all the connected users\n"
                        + "4. ");
        }
    }
}
